スクロール視差効果を作成する
カードのリスト(画像を含む)をスクロールすると、 例) アプリでは、次のことに気づくかもしれません。 画像のスクロールが他の部分よりも遅く見える 画面。まるでリスト内のカードのように見えます は前景にありますが、画像自体は横にあります 遠く離れた背景に。この効果は、 視差として知られています。
このレシピでは、次のように構築して視差効果を作成します。 カードのリスト (テキストを含む角が丸い)。 各カードには画像も含まれています。 カードが画面上にスライドすると、 各カード内の画像が下にスライドします。
次のアニメーションはアプリの動作を示しています。
視差項目を保持するリストを作成する
視差スクロール画像を一覧表示するには、 最初にリストを表示する必要があります。
という名前の新しいステートレス ウィジェットを作成します。ParallaxRecipe
。
内部ParallaxRecipe
でウィジェット ツリーを構築します。SingleChildScrollView
そしてColumn
を形成する
リスト。
class ParallaxRecipe extends StatelessWidget {
const ParallaxRecipe({super.key});
@override
Widget build(BuildContext context) {
return const SingleChildScrollView(
child: Column(
children: [],
),
);
}
}
アイテムをテキストと静止画像で表示する
各リスト項目には角丸長方形の背景が表示されます 画像は、世界 7 か所のうちの 1 つを例示しています。 その背景画像の上に重ねられるのは、 場所とその国の名前、 左下に位置します。間に 背景画像とテキストは暗いグラデーション、 可読性が向上します 背景に対するテキストの表示。
というステートレスなウィジェットを実装します。LocationListItem
前述のビジュアルで構成されます。
現時点では、静的を使用してくださいImage
背景用のウィジェット。
後で、そのウィジェットを視差バージョンに置き換えます。
@immutable
class LocationListItem extends StatelessWidget {
const LocationListItem({
super.key,
required this.imageUrl,
required this.name,
required this.country,
});
final String imageUrl;
final String name;
final String country;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: AspectRatio(
aspectRatio: 16 / 9,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Stack(
children: [
_buildParallaxBackground(context),
_buildGradient(),
_buildTitleAndSubtitle(),
],
),
),
),
);
}
Widget _buildParallaxBackground(BuildContext context) {
return Positioned.fill(
child: Image.network(
imageUrl,
fit: BoxFit.cover,
),
);
}
Widget _buildGradient() {
return Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.transparent, Colors.black.withOpacity(0.7)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: const [0.6, 0.95],
),
),
),
);
}
Widget _buildTitleAndSubtitle() {
return Positioned(
left: 20,
bottom: 20,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
Text(
country,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
),
),
],
),
);
}
}
次に、リスト項目をParallaxRecipe
ウィジェット。
class ParallaxRecipe extends StatelessWidget {
const ParallaxRecipe({super.key});
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
child: Column(
children: [
for (final location in locations)
LocationListItem(
imageUrl: location.imageUrl,
name: location.name,
country: location.place,
),
],
),
);
}
}
これで、典型的なスクロール可能なカードのリストが完成しました。 世界の 7 つのユニークな場所を表示します。 次のステップでは、視差効果を 背景画像。
視差効果を実装する
視差スクロール効果はわずかに 背景画像を反対方向に押し出す リストの残りの部分。リスト項目が上にスライドするにつれて 画面上では、各背景画像が少し下にスライドします。 逆に、リスト項目が画面を下にスライドすると、 各背景画像はわずかに上にスライドします。 視覚的には視差が生じます。
視差効果はリスト項目によって異なります。
祖先内の現在の位置Scrollable
。
リスト項目のスクロール位置が変化すると、その位置は
リスト項目の背景画像も変更する必要があります。
これは解決すべき興味深い問題です。位置
内のリスト項目のScrollable
ではありません
Flutter のレイアウト段階が完了するまで利用できます。
これは、背景画像の位置を意味します。
後の塗装段階で決定する必要があります。
レイアウト段階。幸いなことに、Flutter にはウィジェットが用意されています。
呼ばれたFlow
を提供するために特別に設計されています。
子ウィジェットの変換を即座に制御します
ウィジェットがペイントされる前。言い換えると、
ペイントフェーズを中断して制御することができます
子ウィジェットの位置を自由に変更できます。
背景を包み込むImage
ウィジェット付きFlow
ウィジェット。
Widget _buildParallaxBackground(BuildContext context) {
return Flow(
children: [
Image.network(
imageUrl,
fit: BoxFit.cover,
),
],
);
}
新しいものを導入するFlowDelegate
呼ばれたParallaxFlowDelegate
。
Widget _buildParallaxBackground(BuildContext context) {
return Flow(
delegate: ParallaxFlowDelegate(),
children: [
Image.network(
imageUrl,
fit: BoxFit.cover,
),
],
);
}
class ParallaxFlowDelegate extends FlowDelegate {
ParallaxFlowDelegate();
@override
BoxConstraints getConstraintsForChild(int i, BoxConstraints constraints) {
// TODO: We'll add more to this later.
}
@override
void paintChildren(FlowPaintingContext context) {
// TODO: We'll add more to this later.
}
@override
bool shouldRepaint(covariant FlowDelegate oldDelegate) {
// TODO: We'll add more to this later.
return true;
}
}
あFlowDelegate
子のサイズを制御する
そしてそれらの子供たちが描かれている場所。この場合、
あなたのFlow
ウィジェットには子が 1 つだけあります: 背景
画像。その画像の幅は、Flow
ウィジェット。
背景画像の子の厳密な幅制約を返します。
@override
BoxConstraints getConstraintsForChild(int i, BoxConstraints constraints) {
return BoxConstraints.tightFor(
width: constraints.maxWidth,
);
}
背景画像のサイズが適切になりました。 ただし、垂直位置を計算する必要があります。 スクロールに基づいて各背景画像を表示 位置を決めてからペイントします。
重要な情報が 3 つあります。 の望ましい位置を計算する必要があります 背景画像:
- 先祖の限界
Scrollable
- 個々のリスト項目の境界
- 縮小後の画像のサイズ リスト項目に収まるように
の境界を調べるには、Scrollable
、
あなたは合格しますScrollableState
あなたの中にFlowDelegate
。
個々のリスト項目の境界を調べるには、
リスト項目を渡しますBuildContext
あなたの中にFlowDelegate
。
背景画像の最終的なサイズを調べるには、
あなたはを割り当てますGlobalKey
あなたへImage
ウィジェット、
そしてあなたはそれを渡しますGlobalKey
あなたの中にFlowDelegate
。
この情報を利用できるようにするParallaxFlowDelegate
。
@immutable
class LocationListItem extends StatelessWidget {
final GlobalKey _backgroundImageKey = GlobalKey();
Widget _buildParallaxBackground(BuildContext context) {
return Flow(
delegate: ParallaxFlowDelegate(
scrollable: Scrollable.of(context),
listItemContext: context,
backgroundImageKey: _backgroundImageKey,
),
children: [
Image.network(
imageUrl,
key: _backgroundImageKey,
fit: BoxFit.cover,
),
],
);
}
}
class ParallaxFlowDelegate extends FlowDelegate {
ParallaxFlowDelegate({
required this.scrollable,
required this.listItemContext,
required this.backgroundImageKey,
});
final ScrollableState scrollable;
final BuildContext listItemContext;
final GlobalKey backgroundImageKey;
}
実装に必要な情報がすべて揃っている
視差スクロールを実装するshouldRepaint()
方法。
@override
bool shouldRepaint(ParallaxFlowDelegate oldDelegate) {
return scrollable != oldDelegate.scrollable ||
listItemContext != oldDelegate.listItemContext ||
backgroundImageKey != oldDelegate.backgroundImageKey;
}
次に、視差効果のためのレイアウト計算を実装します。
まず、リストのピクセル位置を計算します。
祖先内のアイテムScrollable
。
@override
void paintChildren(FlowPaintingContext context) {
// Calculate the position of this list item within the viewport.
final scrollableBox = scrollable.context.findRenderObject() as RenderBox;
final listItemBox = listItemContext.findRenderObject() as RenderBox;
final listItemOffset = listItemBox.localToGlobal(
listItemBox.size.centerLeft(Offset.zero),
ancestor: scrollableBox);
}
リスト項目のピクセル位置を使用して、その値を計算します。
上からの割合Scrollable
。
スクロール可能な領域の上部にあるリスト項目は、
0% が生成され、リスト項目がその下部に表示されます。
スクロール可能な領域は 100% になるはずです。
@override
void paintChildren(FlowPaintingContext context) {
// Calculate the position of this list item within the viewport.
final scrollableBox = scrollable.context.findRenderObject() as RenderBox;
final listItemBox = listItemContext.findRenderObject() as RenderBox;
final listItemOffset = listItemBox.localToGlobal(
listItemBox.size.centerLeft(Offset.zero),
ancestor: scrollableBox);
}
// Determine the percent position of this list item within the
// scrollable area.
final viewportDimension = scrollable.position.viewportDimension;
final scrollFraction =
(listItemOffset.dy / viewportDimension).clamp(0.0, 1.0);
}
スクロール率を使用して計算します。Alignment
。
0% の場合は、Alignment(0.0, -1.0)
、
100% であれば、Alignment(0.0, 1.0)
。
これらの座標は上下に対応します
それぞれアライメントを調整します。
@override
void paintChildren(FlowPaintingContext context) {
// Calculate the position of this list item within the viewport.
final scrollableBox = scrollable.context.findRenderObject() as RenderBox;
final listItemBox = listItemContext.findRenderObject() as RenderBox;
final listItemOffset = listItemBox.localToGlobal(
listItemBox.size.centerLeft(Offset.zero),
ancestor: scrollableBox);
}
// Determine the percent position of this list item within the
// scrollable area.
final viewportDimension = scrollable.position.viewportDimension;
final scrollFraction =
(listItemOffset.dy / viewportDimension).clamp(0.0, 1.0);
}
// Calculate the vertical alignment of the background
// based on the scroll percent.
final verticalAlignment = Alignment(0.0, scrollFraction * 2 - 1);
}
使用verticalAlignment
のサイズとともに、
リスト項目と背景画像のサイズ、
を生産するRect
それがどこにあるかを決定します
背景画像を配置する必要があります。
@override
void paintChildren(FlowPaintingContext context) {
// Calculate the position of this list item within the viewport.
final scrollableBox = scrollable.context.findRenderObject() as RenderBox;
final listItemBox = listItemContext.findRenderObject() as RenderBox;
final listItemOffset = listItemBox.localToGlobal(
listItemBox.size.centerLeft(Offset.zero),
ancestor: scrollableBox);
}
// Determine the percent position of this list item within the
// scrollable area.
final viewportDimension = scrollable.position.viewportDimension;
final scrollFraction =
(listItemOffset.dy / viewportDimension).clamp(0.0, 1.0);
}
// Calculate the vertical alignment of the background
// based on the scroll percent.
final verticalAlignment = Alignment(0.0, scrollFraction * 2 - 1);
}
// Convert the background alignment into a pixel offset for
// painting purposes.
final backgroundSize =
(backgroundImageKey.currentContext!.findRenderObject() as RenderBox)
.size;
final listItemSize = context.size;
final childRect =
verticalAlignment.inscribe(backgroundSize, Offset.zero & listItemSize);
}
使用するchildRect
、背景画像をペイントします
目的の翻訳変換。
時間の経過とともに起こるこの変化こそが、
視差効果。
@override
void paintChildren(FlowPaintingContext context) {
// Calculate the position of this list item within the viewport.
final scrollableBox = scrollable.context.findRenderObject() as RenderBox;
final listItemBox = listItemContext.findRenderObject() as RenderBox;
final listItemOffset = listItemBox.localToGlobal(
listItemBox.size.centerLeft(Offset.zero),
ancestor: scrollableBox);
}
// Determine the percent position of this list item within the
// scrollable area.
final viewportDimension = scrollable.position.viewportDimension;
final scrollFraction =
(listItemOffset.dy / viewportDimension).clamp(0.0, 1.0);
}
// Calculate the vertical alignment of the background
// based on the scroll percent.
final verticalAlignment = Alignment(0.0, scrollFraction * 2 - 1);
}
// Convert the background alignment into a pixel offset for
// painting purposes.
final backgroundSize =
(backgroundImageKey.currentContext!.findRenderObject() as RenderBox)
.size;
final listItemSize = context.size;
final childRect =
verticalAlignment.inscribe(backgroundSize, Offset.zero & listItemSize);
}
// Paint the background.
context.paintChild(
0,
transform:
Transform.translate(offset: Offset(0.0, childRect.top)).transform,
);
}
視差効果を実現するには、最後に 1 つの詳細が必要です。
のParallaxFlowDelegate
入力が変更されたときに再描画します。
しかしParallaxFlowDelegate
毎回塗り直すわけではない
スクロール位置が変わります。
を渡すScrollableState
のScrollPosition
に
のFlowDelegate
スーパークラスなので、FlowDelegate
毎回再描画しますScrollPosition
変化します。
class ParallaxFlowDelegate extends FlowDelegate {
ParallaxFlowDelegate({
required this.scrollable,
required this.listItemContext,
required this.backgroundImageKey,
}) : super(repaint: scrollable.position);
}
おめでとう! これで視差のあるカードのリストができました。 背景画像をスクロールします。
インタラクティブな例
アプリを実行します。
- 上下にスクロールして視差効果を観察します。
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
const Color darkBlue = Color.fromARGB(255, 18, 32, 47);
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData.dark().copyWith(scaffoldBackgroundColor: darkBlue),
debugShowCheckedModeBanner: false,
home: const Scaffold(
body: Center(
child: ExampleParallax(),
),
),
);
}
}
class ExampleParallax extends StatelessWidget {
const ExampleParallax({
super.key,
});
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
child: Column(
children: [
for (final location in locations)
LocationListItem(
imageUrl: location.imageUrl,
name: location.name,
country: location.place,
),
],
),
);
}
}
class LocationListItem extends StatelessWidget {
LocationListItem({
super.key,
required this.imageUrl,
required this.name,
required this.country,
});
final String imageUrl;
final String name;
final String country;
final GlobalKey _backgroundImageKey = GlobalKey();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: AspectRatio(
aspectRatio: 16 / 9,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Stack(
children: [
_buildParallaxBackground(context),
_buildGradient(),
_buildTitleAndSubtitle(),
],
),
),
),
);
}
Widget _buildParallaxBackground(BuildContext context) {
return Flow(
delegate: ParallaxFlowDelegate(
scrollable: Scrollable.of(context),
listItemContext: context,
backgroundImageKey: _backgroundImageKey,
),
children: [
Image.network(
imageUrl,
key: _backgroundImageKey,
fit: BoxFit.cover,
),
],
);
}
Widget _buildGradient() {
return Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.transparent, Colors.black.withOpacity(0.7)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: const [0.6, 0.95],
),
),
),
);
}
Widget _buildTitleAndSubtitle() {
return Positioned(
left: 20,
bottom: 20,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
Text(
country,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
),
),
],
),
);
}
}
class ParallaxFlowDelegate extends FlowDelegate {
ParallaxFlowDelegate({
required this.scrollable,
required this.listItemContext,
required this.backgroundImageKey,
}) : super(repaint: scrollable.position);
final ScrollableState scrollable;
final BuildContext listItemContext;
final GlobalKey backgroundImageKey;
@override
BoxConstraints getConstraintsForChild(int i, BoxConstraints constraints) {
return BoxConstraints.tightFor(
width: constraints.maxWidth,
);
}
@override
void paintChildren(FlowPaintingContext context) {
// Calculate the position of this list item within the viewport.
final scrollableBox = scrollable.context.findRenderObject() as RenderBox;
final listItemBox = listItemContext.findRenderObject() as RenderBox;
final listItemOffset = listItemBox.localToGlobal(
listItemBox.size.centerLeft(Offset.zero),
ancestor: scrollableBox);
// Determine the percent position of this list item within the
// scrollable area.
final viewportDimension = scrollable.position.viewportDimension;
final scrollFraction =
(listItemOffset.dy / viewportDimension).clamp(0.0, 1.0);
// Calculate the vertical alignment of the background
// based on the scroll percent.
final verticalAlignment = Alignment(0.0, scrollFraction * 2 - 1);
// Convert the background alignment into a pixel offset for
// painting purposes.
final backgroundSize =
(backgroundImageKey.currentContext!.findRenderObject() as RenderBox)
.size;
final listItemSize = context.size;
final childRect =
verticalAlignment.inscribe(backgroundSize, Offset.zero & listItemSize);
// Paint the background.
context.paintChild(
0,
transform:
Transform.translate(offset: Offset(0.0, childRect.top)).transform,
);
}
@override
bool shouldRepaint(ParallaxFlowDelegate oldDelegate) {
return scrollable != oldDelegate.scrollable ||
listItemContext != oldDelegate.listItemContext ||
backgroundImageKey != oldDelegate.backgroundImageKey;
}
}
class Parallax extends SingleChildRenderObjectWidget {
const Parallax({
super.key,
required Widget background,
}) : super(child: background);
@override
RenderObject createRenderObject(BuildContext context) {
return RenderParallax(scrollable: Scrollable.of(context));
}
@override
void updateRenderObject(
BuildContext context, covariant RenderParallax renderObject) {
renderObject.scrollable = Scrollable.of(context);
}
}
class ParallaxParentData extends ContainerBoxParentData<RenderBox> {}
class RenderParallax extends RenderBox
with RenderObjectWithChildMixin<RenderBox>, RenderProxyBoxMixin {
RenderParallax({
required ScrollableState scrollable,
}) : _scrollable = scrollable;
ScrollableState _scrollable;
ScrollableState get scrollable => _scrollable;
set scrollable(ScrollableState value) {
if (value != _scrollable) {
if (attached) {
_scrollable.position.removeListener(markNeedsLayout);
}
_scrollable = value;
if (attached) {
_scrollable.position.addListener(markNeedsLayout);
}
}
}
@override
void attach(covariant PipelineOwner owner) {
super.attach(owner);
_scrollable.position.addListener(markNeedsLayout);
}
@override
void detach() {
_scrollable.position.removeListener(markNeedsLayout);
super.detach();
}
@override
void setupParentData(covariant RenderObject child) {
if (child.parentData is! ParallaxParentData) {
child.parentData = ParallaxParentData();
}
}
@override
void performLayout() {
size = constraints.biggest;
// Force the background to take up all available width
// and then scale its height based on the image's aspect ratio.
final background = child!;
final backgroundImageConstraints =
BoxConstraints.tightFor(width: size.width);
background.layout(backgroundImageConstraints, parentUsesSize: true);
// Set the background's local offset, which is zero.
(background.parentData as ParallaxParentData).offset = Offset.zero;
}
@override
void paint(PaintingContext context, Offset offset) {
// Get the size of the scrollable area.
final viewportDimension = scrollable.position.viewportDimension;
// Calculate the global position of this list item.
final scrollableBox = scrollable.context.findRenderObject() as RenderBox;
final backgroundOffset =
localToGlobal(size.centerLeft(Offset.zero), ancestor: scrollableBox);
// Determine the percent position of this list item within the
// scrollable area.
final scrollFraction =
(backgroundOffset.dy / viewportDimension).clamp(0.0, 1.0);
// Calculate the vertical alignment of the background
// based on the scroll percent.
final verticalAlignment = Alignment(0.0, scrollFraction * 2 - 1);
// Convert the background alignment into a pixel offset for
// painting purposes.
final background = child!;
final backgroundSize = background.size;
final listItemSize = size;
final childRect =
verticalAlignment.inscribe(backgroundSize, Offset.zero & listItemSize);
// Paint the background.
context.paintChild(
background,
(background.parentData as ParallaxParentData).offset +
offset +
Offset(0.0, childRect.top));
}
}
class Location {
const Location({
required this.name,
required this.place,
required this.imageUrl,
});
final String name;
final String place;
final String imageUrl;
}
const urlPrefix =
'https://docs.flutter.dev/cookbook/img-files/effects/parallax';
const locations = [
Location(
name: 'Mount Rushmore',
place: 'U.S.A',
imageUrl: '$urlPrefix/01-mount-rushmore.jpg',
),
Location(
name: 'Gardens By The Bay',
place: 'Singapore',
imageUrl: '$urlPrefix/02-singapore.jpg',
),
Location(
name: 'Machu Picchu',
place: 'Peru',
imageUrl: '$urlPrefix/03-machu-picchu.jpg',
),
Location(
name: 'Vitznau',
place: 'Switzerland',
imageUrl: '$urlPrefix/04-vitznau.jpg',
),
Location(
name: 'Bali',
place: 'Indonesia',
imageUrl: '$urlPrefix/05-bali.jpg',
),
Location(
name: 'Mexico City',
place: 'Mexico',
imageUrl: '$urlPrefix/06-mexico-city.jpg',
),
Location(
name: 'Cairo',
place: 'Egypt',
imageUrl: '$urlPrefix/07-cairo.jpg',
),
];